示例 PRO

import {
  Button, CaptureVideoPreviewView, HStack, Image,
  Navigation, NavigationStack, Script, Spacer, Text, Toolbar, ToolbarItem,
  useEffect, useMemo, useObservable, VStack
} from "scripting"

type Shot = {
  index: number
  image: UIImage
  photoSize: string
  /** native 写盘的原始 still bytes(HEIC/JPEG),含 Live Photo asset identifier */
  photoURL?: string
  movieURL?: string
  movieSize?: number
  ms: number
  isDeferredProxy: boolean
}

function View() {
  const dismiss = Navigation.useDismiss()
  const isRunning = useObservable(false)
  const shots = useObservable<Shot[]>([])
  const captureCount = useObservable(0)
  const lastError = useObservable("")
  const livePhotoSupp = useObservable(false)
  const livePhotoEn = useObservable(false)

  const { session, camera, photoOutput } = useMemo(() => {
    const camera = AVCaptureDevice.default("video")!
    const session = new AVCaptureSession()
    const input = new AVCaptureDeviceInput(camera)
    const photoOutput = new AVCapturePhotoOutput()

    session.configure(() => {
      session.sessionPreset = "photo"
      if (session.canAddInput(input)) session.addInput(input)
      if (session.canAddOutput(photoOutput)) session.addOutput(photoOutput)
    })

    // 必须在 addOutput 之后查 supported,在那之前 photoOutput 没绑 session 时 supported=false。
    return { session, camera, photoOutput }
  }, [])

  useEffect(() => {
    async function start() {
      try {
        await session.startRunning()
        isRunning.setValue(true)

        // Live Photo 必须开启 enabled 之后才会真正生效。
        photoOutput.isLivePhotoCaptureEnabled = true
        // 读回真实值: 不支持的设备 setter 会被 clamp 回 false。
        livePhotoSupp.setValue(photoOutput.isLivePhotoCaptureSupported)
        livePhotoEn.setValue(photoOutput.isLivePhotoCaptureEnabled)

        console.log("[startRunning]", {
          preset: session.sessionPreset,
          liveSupp: photoOutput.isLivePhotoCaptureSupported,
          liveEn:   photoOutput.isLivePhotoCaptureEnabled,
        })
      } catch (e) {
        await Dialog.alert({ message: `Failed to start: ${String(e)}` })
        dismiss()
      }
    }
    start()
    return () => {
      session.stopRunning().finally(() => session.dispose())
    }
  }, [])

  async function takeLivePhoto() {
    const idx = captureCount.value + 1
    captureCount.setValue(idx)
    const ts = Date.now()
    // 同时让 native 写盘 still + .mov。两份文件共享 Apple Maker Note 里的
    // Live Photo asset identifier, Photos.saveLivePhoto 才能 pair 上。
    const photoFile = `${FileManager.documentsDirectory}/live_${idx}_${ts}.heic`
    const movieFile = `${FileManager.documentsDirectory}/live_${idx}_${ts}.mov`
    const start = Date.now()
    try {
      const result = await photoOutput.capturePhoto({
        codec: "hevc",
        photoFile,
        livePhotoMovieFile: movieFile,
        livePhotoVideoCodec: "hevc",
      })
      const ms = Date.now() - start

      // 读 movie 文件大小供 UI 显示
      let movieSize: number | undefined
      if (result.livePhotoMovieFileURL) {
        try {
          const stat = await FileManager.stat(result.livePhotoMovieFileURL)
          movieSize = stat.size
        } catch { /* ignore */ }
      }

      shots.setValue([{
        index: idx,
        image: result.image,
        photoSize: `${result.image.width}×${result.image.height}`,
        photoURL: result.photoFileURL,
        movieURL: result.livePhotoMovieFileURL,
        movieSize,
        ms,
        isDeferredProxy: result.isDeferredProxy,
      }, ...shots.value].slice(0, 6))
      lastError.setValue("")
    } catch (e) {
      lastError.setValue(String(e))
    }
  }

  /** 不开 Live Photo,验证退化路径:resolve 不应包含 livePhotoMovieFileURL */
  async function takeRegular() {
    const idx = captureCount.value + 1
    captureCount.setValue(idx)
    const start = Date.now()
    try {
      const result = await photoOutput.capturePhoto({ codec: "hevc" })
      shots.setValue([{
        index: idx,
        image: result.image,
        photoSize: `${result.image.width}×${result.image.height}`,
        movieURL: result.livePhotoMovieFileURL,  // 应为 undefined
        ms: Date.now() - start,
        isDeferredProxy: result.isDeferredProxy,
      }, ...shots.value].slice(0, 6))
      lastError.setValue("")
    } catch (e) {
      lastError.setValue(String(e))
    }
  }

  /**
   * 把最新一张 Live Photo 配对存进系统照片库。
   * 关键: `imagePath` 必须用 native 写盘的 `result.photoFileURL`(原始 HEIC,
   * 含 Apple Maker Note 里的 Live Photo asset identifier),用 toJPEGData 重编码
   * 出来的 JPEG 会丢这个 identifier, Photos.saveLivePhoto 报 PHPhotosError 3302。
   * `shouldMoveFile: true` 会把磁盘上两个文件 move 进 Photos,本地副本就消失了。
   */
  async function saveLast() {
    const last = shots.value[0]
    if (!last) {
      lastError.setValue("Capture a Live Photo first")
      return
    }
    if (!last.photoURL || !last.movieURL) {
      lastError.setValue("Last capture missing photoURL or movieURL (was it a Regular shot?)")
      return
    }
    try {
      await Photos.saveLivePhoto({
        imagePath: last.photoURL,
        videoPath: last.movieURL,
        shouldMoveFile: true,
      })
      lastError.setValue(`Saved Live Photo #${last.index} to Photos library`)
    } catch (e) {
      lastError.setValue(`Save failed: ${String(e)}`)
      console.error(e)
    }
  }

  /** 故意关掉 enabled 再调 Live Photo,期望 promise reject */
  async function probeReject() {
    photoOutput.isLivePhotoCaptureEnabled = false
    livePhotoEn.setValue(photoOutput.isLivePhotoCaptureEnabled)
    try {
      await photoOutput.capturePhoto({
        livePhotoMovieFile: `${FileManager.documentsDirectory}/should_reject.mov`,
      })
      lastError.setValue("Unexpected: did not reject")
    } catch (e) {
      lastError.setValue(`OK (rejected): ${String(e)}`)
    } finally {
      // 恢复 enabled 方便继续测
      photoOutput.isLivePhotoCaptureEnabled = true
      livePhotoEn.setValue(photoOutput.isLivePhotoCaptureEnabled)
    }
  }

  return (
    <NavigationStack>
      <VStack
        navigationTitle="Live Photo capture"
        toolbar={
          <Toolbar>
            <ToolbarItem placement="topBarTrailing">
              <Button title="Done" systemImage="xmark" action={dismiss} />
            </ToolbarItem>
          </Toolbar>
        }
      >
        <CaptureVideoPreviewView
          session={session}
          videoDevice={camera}
          videoGravity="resizeAspectFill"
          frame={{ height: 280 }}
          cornerRadius={12}
          masksToBounds
        />

        <VStack alignment="leading" spacing={4} padding={8}>
          <Text font="caption">Status</Text>
          <Text font="footnote">
            running: {String(isRunning.value)} · liveSupp: {String(livePhotoSupp.value)} · liveEn: {String(livePhotoEn.value)}
          </Text>
          {lastError.value ? (
            <Text font="footnote" foregroundStyle="red">
              {lastError.value}
            </Text>
          ) : null}
        </VStack>

        <HStack padding={8} spacing={10}>
          <Button title="Live Photo" action={takeLivePhoto} />
          <Button title="Regular" action={takeRegular} />
          <Button title="Save last" action={saveLast} />
          <Button title="Probe reject" action={probeReject} />
          <Spacer />
          <Text font="footnote" foregroundStyle="secondaryLabel">
            shots: {captureCount.value}
          </Text>
        </HStack>

        {shots.value[0] ? (
          <Image
            image={shots.value[0].image}
            resizable
            scaleToFit
            frame={{ height: 140 }}
          />
        ) : null}

        <VStack alignment="leading" spacing={2} padding={8}>
          <Text font="caption">Recent captures (newest first)</Text>
          {shots.value.map(s => (
            <Text key={s.index} font="footnote">
              #{s.index} · {s.ms}ms · {s.photoSize}
              {s.isDeferredProxy ? " · proxy" : ""}
              {s.movieURL
                ? ` · mov ${s.movieSize ? Math.round(s.movieSize / 1024) + "KB" : "?"}`
                : " · no .mov"}
            </Text>
          ))}
        </VStack>
      </VStack>
    </NavigationStack>
  )
}

async function run() {
  await Navigation.present({ element: <View /> })
  Script.exit()
}

run()